17 — 粒子
介绍
粒子(particle)正是你从这个名字中所期待的东西。它们非常流行,可以用于实现各种效果,比如星星、烟雾、雨、灰尘、火,以及许多其他东西。
粒子的好处是,你可以让屏幕上同时有几十万个粒子,帧率仍然合理。缺点是每个粒子都由一个始终面向相机的平面(两个三角形)组成。
创建粒子和创建 Mesh 一样简单。我们需要一个 BufferGeometry、一个能处理粒子的材质(PointsMaterial),并且不是生成 Mesh,而是需要创建一个 Points。
设置
starter 只由场景中间的一个立方体组成。那个立方体用来确保一切正常。
第一批粒子
让我们移除立方体,先创建一个由粒子组成的球体。
几何体
你可以使用任何基础的 Three.js 几何体。和 Mesh 一样的原因,最好使用 BufferGeometry。几何体的每个顶点都会变成一个粒子:
/**
* Particles
*/
// Geometry
const particlesGeometry = new THREE.SphereGeometry(1, 32, 32)
PointsMaterial
我们需要一种叫做 PointsMaterial 的专用材质。这种材质已经能做很多事了,但我们在未来的课程里会学习如何创建自己的粒子材质,走得更远。
PointsMaterial 有多个粒子专属的属性,比如控制所有粒子大小的 size,以及指定远处的粒子是否应该比近处的粒子更小的 sizeAttenuation:
// Material
const particlesMaterial = new THREE.PointsMaterial({
size: 0.02,
sizeAttenuation: true
}) 和往常一样,我们也可以在创建材质之后修改这些属性:
const particlesMaterial = new THREE.PointsMaterial()
particlesMaterial.size = 0.02
particlesMaterial.sizeAttenuation = true
Points
最后,我们可以用创建 Mesh 同样的方式创建最终的粒子,只是这次使用 Points 类。别忘了把它加到场景里:
// Points
const particles = new THREE.Points(particlesGeometry, particlesMaterial)
scene.add(particles)
这很简单。让我们来定制这些粒子。
自定义几何体
要创建自定义几何体,我们可以从 BufferGeometry 开始,并像我们在几何体课程里做的那样添加 position attribute。把 SphereGeometry 替换成自定义几何体,像之前那样添加 'position' attribute:
// Geometry
const particlesGeometry = new THREE.BufferGeometry()
const count = 500
const positions = new Float32Array(count * 3) // Multiply by 3 because each position is composed of 3 values (x, y, z)
for(let i = 0; i < count * 3; i++) // Multiply by 3 for same reason
{
positions[i] = (Math.random() - 0.5) * 10 // Math.random() - 0.5 to have a random value between -0.5 and +0.5
}
particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)) // Create the Three.js BufferAttribute and specify that each information is composed of 3 values
如果你自己写不出这段代码,别灰心。它有点复杂,而且变量用了奇怪的格式。
你应该会得到一大片散布在场景里的粒子。现在是探索乐趣、测试你电脑极限的绝佳时机。试试 5000、50000,也许 500000。你可以有几百万个粒子,帧率仍然合理。
你可以想象是有极限的。在配置较差的电脑或手机上,你无法用几百万个粒子获得 60fps 的体验。我们还会添加一些会急剧降低帧率的效果。不过,这已经相当令人印象深刻了。
现在,让我们把数量保持为 5000,把大小改成 0.1:
const count = 5000
// ...
particlesMaterial.size = 0.1
// ...
颜色、贴图和 alpha 贴图
我们可以用 PointsMaterial 上的 color 属性改变所有粒子的颜色。别忘了,如果你是在实例化材质之后修改这个属性,需要使用 Color 类:
particlesMaterial.color = new THREE.Color('#ff88cc') 我们也可以使用 map 属性把纹理贴到这些粒子上。使用代码中已有的 TextureLoader 加载位于 /static/textures/particles/ 的一张纹理:
/**
* Textures
*/
const textureLoader = new THREE.TextureLoader()
const particleTexture = textureLoader.load('/textures/particles/2.png')
// ...
particlesMaterial.map = particleTexture
这些纹理是 Kenney 提供的资源包的缩放版,你可以在这里找到完整资源包:https://www.kenney.nl/assets/particle-pack。但你也可能自己创建。
如你所见,color 属性会改变贴图,就像其他材质一样。
如果仔细看,你会发现前面的粒子挡住了后面的粒子。
我们需要用 transparent 激活透明,并用纹理作为 alphaMap 属性,而不是 map:
// particlesMaterial.map = particleTexture
particlesMaterial.transparent = true
particlesMaterial.alphaMap = particleTexture
这下好多了,但我们仍然偶尔能看到粒子的边缘。
那是因为粒子是按创建的先后顺序绘制的,而 WebGL 并不真正知道哪个粒子在另一个的前面。
修复这个有多种方式。
使用 alphaTest
alphaTest 是 0 到 1 之间的一个值,它让 WebGL 能根据像素的透明度知道什么时候不渲染该像素。默认情况下,值是 0,意思是像素无论如何都会被渲染。如果我们用一个小值,比如 0.001,那么当 alpha 是 0 时像素就不会被渲染:
particlesMaterial.alphaTest = 0.001
这个方案并不完美,如果你仔细看,仍然能看到一些小故障,但已经更令人满意了。
使用 depthTest
绘制时,WebGL 会测试正在绘制的内容是否比已经绘制的内容更近。这叫做深度测试(depth testing),可以被停用(你可以把 alphaTest 注释掉):
// particlesMaterial.alphaTest = 0.001
particlesMaterial.depthTest = false
虽然这个方案似乎完全解决了我们的问题,但如果你的场景里还有其他物体或不同颜色的粒子,停用深度测试可能产生 bug。粒子可能会被绘制得像在场景其他部分的上方。
给场景加一个立方体看看:
const cube = new THREE.Mesh(
new THREE.BoxGeometry(),
new THREE.MeshBasicMaterial()
)
scene.add(cube)
使用 depthWrite
正如我们所说,WebGL 会测试正在绘制的内容是否比已经绘制的内容更近。正在绘制的内容的深度被存储在所谓深度缓冲(depth buffer)里。与其不测试粒子是否比深度缓冲中的内容更近,我们可以告诉 WebGL 不要把粒子写进深度缓冲(你可以把 depthTest 注释掉):
// particlesMaterial.alphaTest = 0.001
// particlesMaterial.depthTest = false
particlesMaterial.depthWrite = false
在我们的情况里,这个方案几乎没有任何缺点地解决了问题。有时候,其他物体可能会被绘制在粒子的后面或前面,这取决于很多因素,比如透明度、你往场景里添加物体的顺序等等。
我们看了好几种技术,没有一个是完美方案。你得根据项目去适应并找到最佳组合。
混合
目前,WebGL 是一个像素一个像素地叠加绘制的。
通过修改 blending 属性,我们可以让 WebGL 不只是绘制像素,还把那个像素的颜色叠加到已经绘制的像素颜色上。这会带来一种饱和效果,看起来会非常棒。
要测试这个,只需把 blending 属性改成 THREE.AdditiveBlending(保留 depthWrite 属性):
// particlesMaterial.alphaTest = 0.001
// particlesMaterial.depthTest = false
particlesMaterial.depthWrite = false
particlesMaterial.blending = THREE.AdditiveBlending
添加更多粒子(比如 20000),更好地欣赏这个效果。
但要小心,这个效果会影响性能,你将无法像之前一样拥有那么多粒子还能维持在 60fps。
现在,我们可以移除 cube 了。
不同的颜色
我们可以让每个粒子有不同的颜色。首先需要像为 position 做的那样,添加一个名为 color 的新 attribute。一个颜色由红、绿、蓝组成(3 个值),所以代码会与 position attribute 非常相似。实际上我们可以让这两个 attribute 共用同一个循环:
const positions = new Float32Array(count * 3)
const colors = new Float32Array(count * 3)
for(let i = 0; i < count * 3; i++)
{
positions[i] = (Math.random() - 0.5) * 10
colors[i] = Math.random()
}
particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
particlesGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)) 小心单数和复数。
要激活这些顶点颜色,只需把 vertexColors 属性改成 true:
particlesMaterial.vertexColors = true
材质的主颜色仍然会影响这些顶点颜色。你可以随意修改那个颜色,甚至把它注释掉。
// particlesMaterial.color = new THREE.Color('#ff88cc')
动画
动画化粒子有多种方式。
把 points 当作一个物体来使用
因为 Points 类继承自 Object3D 类,你可以随意移动、旋转和缩放 points。
在 tick 函数里旋转粒子:
const tick = () =>
{
const elapsedTime = clock.getElapsedTime()
// Update particles
particles.rotation.y = elapsedTime * 0.2
// ...
}
虽然这已经很酷了,但我们想要对每个粒子有更多控制。
通过修改 attributes
另一个方案是分别更新每个顶点的位置。这样,顶点就能有不同的轨迹。我们要把粒子动画化得像是漂浮在波浪上,但首先,让我们看看如何更新顶点。
先把我们之前在整体 particles 上做的旋转注释掉:
const tick = () =>
{
// ...
// particles.rotation.y = elapsedTime * 0.2
// ...
} 要更新每个顶点,我们得更新 position attribute 里的正确部分,因为所有顶点都存储在这样一个一维数组里:前 3 个值对应第一个顶点的 x、y 和 z 坐标,接着的 3 个值对应第二个顶点的 x、y 和 z,以此类推。
我们只想让顶点上下移动,也就是说我们只更新 y 轴。因为 position attribute 是一维数组,我们必须 3 个 3 个地遍历它,只更新第二个值,也就是 y 坐标。
让我们先遍历每个顶点:
const tick = () =>
{
// ...
for(let i = 0; i < count; i++)
{
const i3 = i * 3
}
// ...
} 这里,我们选择了一个从 0 到 count 的简单 for 循环,并在内部创建了一个 i3 变量,它通过把 i 乘以 3 实现 3 个 3 个地递增。
模拟波浪运动最简单的方式是使用一个简单的正弦。首先,我们要让所有顶点以相同的频率上下移动。
y 坐标可以在这个数组中通过索引 i3 + 1 访问:
const tick = () =>
{
// ...
for(let i = 0; i < count; i++)
{
const i3 = i * 3
particlesGeometry.attributes.position.array[i3 + 1] = Math.sin(elapsedTime)
}
// ...
} 不幸的是,什么东西都没动。问题是 Three.js 必须被告知几何体改变了。要做到这一点,我们在更新完顶点之后,必须把 position attribute 上的 needsUpdate 设为 true:
const tick = () =>
{
// ...
for(let i = 0; i < count; i++)
{
const i3 = i * 3
particlesGeometry.attributes.position.array[i3 + 1] = Math.sin(elapsedTime)
}
particlesGeometry.attributes.position.needsUpdate = true
// ...
}
所有粒子应该像平面一样上下移动。
这是个好的开始,我们快成功了。我们现在要做的就是让粒子之间的正弦产生偏移,从而得到那种波浪形状。
要做到这一点,我们可以使用 x 坐标。为了拿到这个值,我们可以使用和 y 坐标相同的技术,但不用 i3 + 1,而只是 i3:
const tick = () =>
{
// ...
for(let i = 0; i < count; i++)
{
let i3 = i * 3
const x = particlesGeometry.attributes.position.array[i3]
particlesGeometry.attributes.position.array[i3 + 1] = Math.sin(elapsedTime + x)
}
particlesGeometry.attributes.position.needsUpdate = true
// ...
}
你应该会得到漂亮的粒子波浪。不幸的是,你应该避免这个技术。如果我们有 20000 个粒子,我们就要遍历每一个,计算新位置,并在每一帧更新整个 attribute。这在粒子数量少时可以用,但我们想要几百万个粒子。
通过使用自定义 shader
想以好的帧率在每帧更新这几百万个粒子,我们需要用自己的 shader 创建自己的材质。但 shader 是后面课程的事。